page.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. "use client";
  2. import { useCallback, useEffect, useMemo, useState } from "react";
  3. import { useParams, useRouter } from "next/navigation";
  4. import { MessageList } from "@/components/dashboard/MessageList";
  5. import { MessageInput } from "@/components/dashboard/MessageInput";
  6. import { ChatHeader } from "@/components/dashboard/ChatHeader";
  7. import { Button } from "@/components/ui/button";
  8. import { useAuth } from "@/features/agent/hooks/useAuth";
  9. import { useWebSocket } from "@/features/agent/hooks/useWebSocket";
  10. import {
  11. fetchMessages,
  12. sendMessage as sendMessageApi,
  13. markMessagesRead,
  14. } from "@/features/agent/services/messageApi";
  15. import { fetchConversationDetail } from "@/features/agent/services/conversationApi";
  16. import {
  17. ConversationDetail,
  18. ConversationSummary,
  19. MessageItem,
  20. MessagesReadPayload,
  21. ChatWebSocketPayload,
  22. } from "@/features/agent/types";
  23. import type { WSMessage } from "@/lib/websocket";
  24. import { toast } from "@/hooks/useToast";
  25. export default function AgentChatPage() {
  26. const params = useParams();
  27. const router = useRouter();
  28. const { agent, loading: authLoading } = useAuth();
  29. const conversationId = useMemo(() => {
  30. const id = params?.conversationId;
  31. if (!id) {
  32. return null;
  33. }
  34. const parsed = Number.parseInt(String(id), 10);
  35. return Number.isNaN(parsed) ? null : parsed;
  36. }, [params]);
  37. const [messages, setMessages] = useState<MessageItem[]>([]);
  38. const [conversationDetail, setConversationDetail] =
  39. useState<ConversationDetail | null>(null);
  40. const [conversationSummary, setConversationSummary] =
  41. useState<ConversationSummary | null>(null);
  42. const [messageInput, setMessageInput] = useState("");
  43. const [loadingMessages, setLoadingMessages] = useState(true);
  44. const [sending, setSending] = useState(false);
  45. const [highlightKeyword, setHighlightKeyword] = useState("");
  46. const handleMarkMessagesRead = useCallback(
  47. async (
  48. conversationIdParam?: number,
  49. readerIsAgentParam?: boolean
  50. ) => {
  51. const targetConversationId = conversationIdParam ?? conversationId;
  52. const targetReaderIsAgent = readerIsAgentParam ?? true;
  53. if (!targetConversationId) {
  54. return;
  55. }
  56. const result = await markMessagesRead(
  57. targetConversationId,
  58. targetReaderIsAgent
  59. );
  60. if (!result || result.message_ids.length === 0) {
  61. return;
  62. }
  63. const idSet = new Set(result.message_ids);
  64. setMessages((prev) =>
  65. prev.map((msg) =>
  66. idSet.has(msg.id)
  67. ? {
  68. ...msg,
  69. is_read: true,
  70. read_at: result.read_at ?? msg.read_at ?? null,
  71. }
  72. : msg
  73. )
  74. );
  75. if (targetReaderIsAgent) {
  76. setConversationDetail((prev) =>
  77. prev ? { ...prev, unread_count: result.unread_count } : prev
  78. );
  79. setConversationSummary((prev) =>
  80. prev ? { ...prev, unread_count: result.unread_count } : prev
  81. );
  82. } else {
  83. setConversationDetail((prev) =>
  84. prev
  85. ? {
  86. ...prev,
  87. last_seen_at: result.read_at ?? prev.last_seen_at ?? null,
  88. }
  89. : prev
  90. );
  91. }
  92. },
  93. [conversationId]
  94. );
  95. const loadConversationDetail = useCallback(async () => {
  96. if (!conversationId) {
  97. return;
  98. }
  99. const detail = await fetchConversationDetail(conversationId);
  100. if (detail) {
  101. setConversationDetail(detail);
  102. setConversationSummary({
  103. id: detail.id,
  104. visitor_id: detail.visitor_id,
  105. agent_id: detail.agent_id,
  106. status: detail.status,
  107. created_at: detail.created_at,
  108. updated_at: detail.updated_at,
  109. last_message: detail.last_message,
  110. unread_count: detail.unread_count ?? 0,
  111. });
  112. }
  113. }, [conversationId]);
  114. const loadMessages = useCallback(async () => {
  115. if (!conversationId) {
  116. return;
  117. }
  118. setLoadingMessages(true);
  119. try {
  120. const data = await fetchMessages(conversationId);
  121. setMessages(data);
  122. // 注意:不再自动标记访客消息为已读,而是通过滚动检测来处理
  123. } catch (error) {
  124. console.error("拉取消息失败:", error);
  125. } finally {
  126. setLoadingMessages(false);
  127. }
  128. }, [conversationId]);
  129. useEffect(() => {
  130. if (!conversationId) {
  131. router.push("/agent/dashboard");
  132. }
  133. }, [conversationId, router]);
  134. useEffect(() => {
  135. if (!conversationId || !agent) {
  136. return;
  137. }
  138. loadConversationDetail();
  139. loadMessages();
  140. }, [conversationId, agent, loadConversationDetail, loadMessages]);
  141. const handleNewMessage = useCallback(
  142. (message: MessageItem) => {
  143. setMessages((prev) => {
  144. const exists = prev.some((item) => item.id === message.id);
  145. if (exists) {
  146. return prev;
  147. }
  148. return [...prev, message];
  149. });
  150. setConversationDetail((prev) => {
  151. if (!prev) {
  152. return prev;
  153. }
  154. const nextUnread =
  155. !message.sender_is_agent && message.message_type !== "system_message"
  156. ? (prev.unread_count ?? 0) + 1
  157. : prev.unread_count ?? 0;
  158. return {
  159. ...prev,
  160. updated_at: message.created_at,
  161. unread_count:
  162. message.sender_is_agent || message.message_type === "system_message"
  163. ? prev.unread_count ?? 0
  164. : nextUnread,
  165. last_message: {
  166. id: message.id,
  167. content: message.content,
  168. sender_is_agent: message.sender_is_agent,
  169. message_type: message.message_type ?? "user_message",
  170. is_read: Boolean(message.is_read),
  171. read_at: message.read_at ?? null,
  172. created_at: message.created_at,
  173. },
  174. };
  175. });
  176. setConversationSummary((prev) => {
  177. if (!prev) {
  178. return prev;
  179. }
  180. const nextUnread =
  181. !message.sender_is_agent && message.message_type !== "system_message"
  182. ? (prev.unread_count ?? 0) + 1
  183. : prev.unread_count ?? 0;
  184. return {
  185. ...prev,
  186. updated_at: message.created_at,
  187. unread_count:
  188. message.sender_is_agent || message.message_type === "system_message"
  189. ? prev.unread_count ?? 0
  190. : nextUnread,
  191. last_message: {
  192. id: message.id,
  193. content: message.content,
  194. sender_is_agent: message.sender_is_agent,
  195. message_type: message.message_type ?? "user_message",
  196. is_read: Boolean(message.is_read),
  197. read_at: message.read_at ?? null,
  198. created_at: message.created_at,
  199. },
  200. };
  201. });
  202. // 注意:不再自动标记访客消息为已读,而是通过滚动检测来处理
  203. if (message.conversation_id === conversationId) {
  204. loadConversationDetail();
  205. }
  206. },
  207. [conversationId, loadConversationDetail]
  208. );
  209. const handleSendMessage = useCallback(async () => {
  210. if (!conversationId || !agent?.id || !messageInput.trim() || sending) {
  211. return;
  212. }
  213. setSending(true);
  214. try {
  215. const created = await sendMessageApi({
  216. conversationId,
  217. content: messageInput,
  218. senderId: agent.id,
  219. });
  220. setMessageInput("");
  221. if (created) {
  222. handleNewMessage(created);
  223. } else {
  224. await loadMessages();
  225. }
  226. } catch (error) {
  227. console.error(error);
  228. toast.error((error as Error).message);
  229. } finally {
  230. setSending(false);
  231. }
  232. }, [agent?.id, conversationId, messageInput, sending, handleNewMessage, loadMessages]);
  233. const handleMessagesReadEvent = useCallback(
  234. (payload: MessagesReadPayload) => {
  235. if (!conversationId) {
  236. return;
  237. }
  238. // 检查对话ID是否匹配
  239. const payloadConversationId = payload?.conversation_id;
  240. if (payloadConversationId && payloadConversationId !== conversationId) {
  241. return;
  242. }
  243. const ids = Array.isArray(payload?.message_ids)
  244. ? payload.message_ids
  245. : [];
  246. if (ids.length === 0) {
  247. return;
  248. }
  249. const readAt = payload?.read_at;
  250. const readerIsAgent = Boolean(payload?.reader_is_agent);
  251. const unreadCount =
  252. typeof payload?.unread_count === "number"
  253. ? payload.unread_count
  254. : undefined;
  255. // 对于客服端:只有当 reader_is_agent === false 时(访客读取了客服的消息),
  256. // 才更新客服消息(sender_is_agent === true)的已读状态
  257. if (readerIsAgent) {
  258. // 客服读取了访客的消息,更新未读数(不更新消息的已读状态,因为这是客服读取的)
  259. if (unreadCount !== undefined) {
  260. setConversationDetail((prev) =>
  261. prev ? { ...prev, unread_count: unreadCount } : prev
  262. );
  263. setConversationSummary((prev) =>
  264. prev ? { ...prev, unread_count: unreadCount } : prev
  265. );
  266. }
  267. return;
  268. }
  269. // 访客读取了客服的消息,更新客服消息的已读状态
  270. const messageIdSet = new Set(ids);
  271. setMessages((prev) =>
  272. prev.map((msg) =>
  273. // 只更新客服自己的消息(sender_is_agent === true)的已读状态
  274. messageIdSet.has(msg.id) && msg.sender_is_agent
  275. ? {
  276. ...msg,
  277. is_read: true,
  278. read_at: readAt ?? msg.read_at ?? null,
  279. }
  280. : msg
  281. )
  282. );
  283. // 更新访客的最后活跃时间
  284. setConversationDetail((prev) =>
  285. prev
  286. ? { ...prev, last_seen_at: readAt ?? prev.last_seen_at ?? null }
  287. : prev
  288. );
  289. },
  290. [conversationId]
  291. );
  292. const handleWebSocketMessage = useCallback(
  293. (event: WSMessage<ChatWebSocketPayload>) => {
  294. if (!event) {
  295. return;
  296. }
  297. if (event.type === "new_message" && event.data) {
  298. const data = event.data as MessageItem;
  299. if (typeof data.conversation_id === "number") {
  300. handleNewMessage(data);
  301. }
  302. } else if (event.type === "messages_read") {
  303. // 确保处理已读事件,并传入对话ID
  304. const payload = event.data as MessagesReadPayload;
  305. if (!payload.conversation_id && event.conversation_id) {
  306. payload.conversation_id = event.conversation_id;
  307. }
  308. handleMessagesReadEvent(payload);
  309. }
  310. },
  311. [handleMessagesReadEvent, handleNewMessage]
  312. );
  313. useWebSocket<ChatWebSocketPayload>({
  314. conversationId,
  315. enabled: Boolean(conversationId),
  316. onMessage: handleWebSocketMessage,
  317. onError: (error) => {
  318. // 静默处理错误,避免影响用户体验
  319. },
  320. onClose: () => {
  321. // 静默处理关闭,避免影响用户体验
  322. },
  323. });
  324. const handleBack = useCallback(() => {
  325. router.push("/agent/dashboard");
  326. }, [router]);
  327. const unreadCount =
  328. conversationDetail?.unread_count ?? conversationSummary?.unread_count ?? 0;
  329. if (authLoading || !agent) {
  330. return (
  331. <div className="flex justify-center items-center min-h-screen bg-gray-50 text-gray-600">
  332. 加载中...
  333. </div>
  334. );
  335. }
  336. if (!conversationId) {
  337. return null;
  338. }
  339. return (
  340. <div className="flex flex-col h-screen bg-gray-50">
  341. <div className="bg-white border-b border-gray-200">
  342. <div className="flex items-center gap-4 px-4 h-16">
  343. <Button
  344. onClick={handleBack}
  345. variant="outline"
  346. size="sm"
  347. >
  348. ← 返回
  349. </Button>
  350. <div className="flex-1">
  351. <ChatHeader
  352. conversationId={conversationId}
  353. lastSeenAt={conversationDetail?.last_seen_at}
  354. unreadCount={unreadCount}
  355. onMarkAllRead={() => handleMarkMessagesRead(conversationId, true)}
  356. onRefresh={() => {
  357. loadMessages();
  358. loadConversationDetail();
  359. }}
  360. />
  361. </div>
  362. </div>
  363. </div>
  364. <div className="flex-1 flex flex-col bg-white">
  365. <MessageList
  366. messages={messages}
  367. loading={loadingMessages}
  368. highlightKeyword={highlightKeyword}
  369. onHighlightClear={() => setHighlightKeyword("")}
  370. currentUserIsAgent={true}
  371. conversationId={conversationId}
  372. onMarkMessagesRead={handleMarkMessagesRead}
  373. />
  374. <MessageInput
  375. value={messageInput}
  376. onChange={setMessageInput}
  377. onSubmit={handleSendMessage}
  378. sending={sending}
  379. />
  380. </div>
  381. </div>
  382. );
  383. }